/*
 * Copyright (C) 2023 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.google.copybara.util;

import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Ordering;
import com.google.common.collect.Sets;
import com.google.common.hash.HashCode;
import com.google.common.hash.HashFunction;
import com.google.common.io.MoreFiles;
import com.google.copybara.exception.RepoException;
import com.google.copybara.exception.ValidationException;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.stream.Stream;

/**
 * ConsistencyFile represents the difference between what exists in the destination files and the
 * output of an import created by Copybara. This will only be different when using merge import
 * mode, because otherwise the destination files will be overwritten and there will be no
 * difference.
 */
public class ConsistencyFile {
  private static final String REGENERATE_ERR_PROMPT =
      "Consider regenerating the consistency file using the regenerate command.";

  private static final String HEADER =
      "" + "# This file is generated by Copybara.\n" + "# Do not edit.\n";

  private static final String HASH_DELIMITER = "--hash-delimiter--";

  private final ImmutableMap<String, String> fileHashes;
  private final byte[] diffContent;

  public ConsistencyFile(ImmutableMap<String, String> fileHashes, byte[] diffContent) {
    this.fileHashes = fileHashes;
    this.diffContent = diffContent;
  }

  /**
   * Create a ConsistencyFile object from two folders containing separate versions of the
   * repository.
   *
   * <p>The location of the two folders matters. The ConsistencyFile includes a diff between the two
   * folders which the parent of the destination will be used as the working directory for when
   * created. The locations of the folders will affect the paths that appear in the diff output.
   *
   * @param baseline is the version to diff against.
   * @param destination is the version containing all the destination only changes.
   */
  public static ConsistencyFile generate(
      Path baseline,
      Path destination,
      HashFunction hashFunction,
      Map<String, String> environment,
      boolean verbose)
      throws IOException, InsideGitDirException, ValidationException {
    byte[] diff = DiffUtil.diffWithIgnoreCrAtEol(baseline, destination, verbose, environment);
    ImmutableMap<String, String> destinationHashes = computeFileHashes(destination, hashFunction);
    ImmutableList<String> baselineFileNames = getFileNames(baseline);

    FileSetDiff filesetDiff = calculateFileSetDiff(destinationHashes, baselineFileNames);

    if (!filesetDiff.destinationOnly().isEmpty() || !filesetDiff.originOnly().isEmpty()) {
      String message =
          String.format(
              "Error: Detected full-file diffs when generating consistency file: %s. Please adjust"
                  + " destination or origin globs to exclude these files.\n",
              filesetDiff);
      ImmutableSet<String> extraDotFiles =
          filesetDiff.destinationOnly.stream()
              .filter(file -> Path.of(file).getFileName().toString().startsWith("."))
              .collect(toImmutableSet());
      ImmutableSet<String> origFiles =
          filesetDiff.destinationOnly.stream()
              .filter(file -> file.endsWith(".orig"))
              .collect(toImmutableSet());

      if (!extraDotFiles.isEmpty()) {
        message +=
            "If using an hg-based VCS, dot files may not be tracked by the destination, but still"
                + " be present in the workspace.\n";
      }
      if (!origFiles.isEmpty()) {
        message +=
            "If using an hg-based VCS, '.orig' files may need to be cleaned up manually in the"
                + " destination.\n";
      }
      throw new ValidationException(message);
    }

    return new ConsistencyFile(destinationHashes, diff);
  }

  record FileSetDiff(ImmutableList<String> destinationOnly, ImmutableList<String> originOnly) {}

  public static FileSetDiff calculateFileSetDiff(
      ImmutableMap<String, String> destinationHashes, ImmutableList<String> baselineFileNames) {
    ImmutableSet<String> destinationFileSet = destinationHashes.keySet();
    ImmutableSet<String> baselineFileSet = ImmutableSet.copyOf(baselineFileNames);

    ImmutableSet<String> destinationOnly =
        Sets.difference(destinationFileSet, baselineFileSet).immutableCopy();
    ImmutableSet<String> originOnly =
        Sets.difference(baselineFileSet, destinationFileSet).immutableCopy();

    return new FileSetDiff(destinationOnly.asList(), originOnly.asList());
  }

  public static ConsistencyFile generateNoDiff(Path contents, HashFunction hashFunction)
      throws IOException {
    return new ConsistencyFile(computeFileHashes(contents, hashFunction), new byte[0]);
  }

  private static ImmutableList<String> getFileNames(Path directory) throws IOException {
    ImmutableList.Builder<String> namesBuilder = ImmutableList.builder();
    Files.walkFileTree(
        directory,
        new SimpleFileVisitor<>() {
          @Override
          public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
            if (Files.isSymbolicLink(file)) {
              // skip symbolic links
              return FileVisitResult.CONTINUE;
            }
            namesBuilder.add(directory.relativize(file).toString());
            return FileVisitResult.CONTINUE;
          }
        });
    return namesBuilder.build();
  }

  private static ImmutableMap<String, String> computeFileHashes(
      Path directory, HashFunction hashFunction) throws IOException {
    ImmutableMap.Builder<String, String> hashesBuilder = ImmutableMap.builder();
    Files.walkFileTree(
        directory,
        new SimpleFileVisitor<>() {
          @Override
          public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
              throws IOException {
            if (Files.isSymbolicLink(file)) {
              // skip symbolic links
              // if they are materialized, then they will be hashed elsewhere
              // the symbolic link itself will not be hashed
              return FileVisitResult.CONTINUE;
            }
            HashCode hashCode = MoreFiles.asByteSource(file).hash(hashFunction);
            hashesBuilder.put(directory.relativize(file).toString(), hashCode.toString());
            return FileVisitResult.CONTINUE;
          }
        });
    return hashesBuilder.buildKeepingLast();
  }

  private static String mustReadLine(BufferedReader reader) throws IOException {
    String line = reader.readLine();
    if (line == null) {
      throw new IOException("failed to parse consistency file: unexpected end of file");
    }
    return line;
  }

  private static String mustReadUncommentedLine(BufferedReader reader) throws IOException {
    String line = mustReadLine(reader);
    while (line.startsWith("#")) {
      line = mustReadLine(reader);
    }
    return line;
  }

  public static ConsistencyFile fromBytes(byte[] bytes) throws IOException, ValidationException {
    try (ByteArrayInputStream in = new ByteArrayInputStream(bytes);
        BufferedReader br = new BufferedReader(new InputStreamReader(in, UTF_8))) {
      ImmutableMap.Builder<String, String> fileHashesBuilder = new ImmutableMap.Builder<>();

      String line = mustReadUncommentedLine(br);
      while (!line.equals(HASH_DELIMITER)) {
        List<String> splits = Splitter.on(": ").limit(2).splitToList(line);
        if (splits.size() != 2) {
          throw new IOException(
              "failed to parse consistency file hashes: unexpected number of elements");
        }
        validateParsedPathValue(splits.get(0));
        validateParsedHashValue(splits.get(1));
        fileHashesBuilder.put(splits.get(0), splits.get(1));

        line = mustReadUncommentedLine(br);
      }

      line = br.readLine(); // can be null if there is no diff output
      ByteArrayOutputStream diffContentOut = new ByteArrayOutputStream();
      try (OutputStreamWriter diffContentWriter = new OutputStreamWriter(diffContentOut, UTF_8)) {
        while (line != null) {
          diffContentWriter.write(line + "\n");
          line = br.readLine();
        }
      }

      return new ConsistencyFile(fileHashesBuilder.buildOrThrow(), diffContentOut.toByteArray());
    }
  }

  public ImmutableMap<String, String> getFileHashes() {
    return ImmutableMap.copyOf(fileHashes);
  }

  byte[] getDiffContent() {
    return Arrays.copyOf(diffContent, diffContent.length);
  }

  public byte[] toBytes() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();

    // OutputStreamWriter for this part for idiomatic string writing
    try (OutputStreamWriter outWriter = new OutputStreamWriter(out)) {
      outWriter.write(HEADER);

      ArrayList<Entry<String, String>> fileHashesList =
          new ArrayList<>(fileHashes.entrySet().asList());
      fileHashesList.sort(
          Ordering.natural()
              .<Entry<String, String>>onResultOf(Entry::getKey)
              .compound(Ordering.natural().onResultOf(Entry::getValue)));

      for (Entry<String, String> entry : fileHashesList) {
        outWriter.write(String.format("%s: %s\n",
            entry.getKey(),
            entry.getValue()));
      }
      outWriter.write(HASH_DELIMITER + "\n");
    }

    out.write(diffContent);

    return out.toByteArray();
  }

  /**
   * reversePatches applies the diff contained in the patch in reverse on the input destination
   * directory, obtaining the origin directory sans any destination-only changes.
   */
  public void reversePatches(Path dir, Map<String, String> environment)
      throws IOException, ValidationException {
    AutoPatchUtil.reversePatch(dir, this.getDiffContent(), environment);
  }

  /**
   * Functional interface for obtaining a hash, given a file.
   */
  public interface HashGetter {

    String getHashString(String filePath) throws IOException, RepoException;
  }

  /**
   * Return a {@link HashGetter} that obtains the hash by reading the file at the path relative to
   * the passed in directory and hashing it.
   */
  public static HashGetter simpleHashGetter(Path dir, HashFunction hashFunction) {
    return (String path) -> MoreFiles.asByteSource(dir.resolve(path)).hash(hashFunction).toString();
  }

  /**
   * A utility method for obtaining a list of files from a directory.
   */
  public static ImmutableSet<String> filesInDir(Path dir) throws IOException {
    try (Stream<Path> files = Files.walk(dir)) {
      return files
          .filter(file -> !Files.isDirectory(file))
          .map(dir::relativize)
          .map(Path::toString)
          .collect(toImmutableSet());
    }
  }

  /**
   * Validating will apply some checks verify that a directory matches the state of the destination
   * directory used when this Consistency file was created.
   *
   * <p>This check can detect if changes were made to the destination that the ConsistencyFile does
   * not account for. If such changes exist, then it does not make sense to use this ConsistencyFile
   * to construct the baseline, and {@link #reversePatches(Path, Map)} should not be used on the
   * passed-in directory.
   *
   * @param files is a list of relative paths of all files in the directory
   * @param hashFetcher is a function that obtains the hash of a file, given the relative path
   */
  public void validateDirectory(ImmutableSet<String> files, HashGetter hashFetcher)
      throws IOException, ValidationException, RepoException {
    Path root = Path.of("/");

    ImmutableSet<String> directoryFiles = files.stream()
        .map(Path::of)
        .map(root::resolve)
        .map(root::relativize)
        .map(Path::toString)
        .collect(toImmutableSet());

    // check that all directory files are present in file hashes
    ImmutableSet<String> consistencyFileHashes = fileHashes.keySet();
    ImmutableSet<String> directoryOnlyFiles =
        directoryFiles.stream()
            .filter(file -> !consistencyFileHashes.contains(file))
            .collect(toImmutableSet());

    if (!directoryOnlyFiles.isEmpty()) {
      throw new ValidationException(
          String.format(
              "Encountered files in directory not present in ConsistencyFile: %s. %s",
              directoryOnlyFiles, REGENERATE_ERR_PROMPT));
    }

    // check that all ConsistencyFile files are present in directory
    ImmutableSet<String> consistencyFileOnlyHashes =
        consistencyFileHashes.stream()
            .filter(file -> !directoryFiles.contains(file))
            .collect(toImmutableSet());

    if (!consistencyFileOnlyHashes.isEmpty()) {
      throw new ValidationException(
          String.format(
              "Encountered files not found in directory but present in ConsistencyFile: %s. %s",
              consistencyFileHashes, REGENERATE_ERR_PROMPT));
    }

    // verify that all file hashes in the directory match the ConsistencyFile file hashes
    for (Entry<String, String> hashEntry : fileHashes.entrySet()) {
      String fileName = hashEntry.getKey();
      String expectedHash = hashEntry.getValue();
      String actualHash = hashFetcher.getHashString(fileName);
      if (!expectedHash.equals(actualHash)) {
        throw new ValidationException(
            String.format(
                "File %s has hash value %s in ConsistencyFile but %s in directory. %s",
                fileName, expectedHash, actualHash, REGENERATE_ERR_PROMPT));
      }
    }
  }

  @Override
  public boolean equals(Object obj) {
    if (this == obj) {
      return true;
    }

    if (!(obj instanceof ConsistencyFile)) {
      return false;
    }

    ConsistencyFile that = (ConsistencyFile) obj;
    if (!this.fileHashes.equals(that.fileHashes)) {
      return false;
    }

    return Arrays.equals(this.diffContent, that.diffContent);
  }

  @Override
  public int hashCode() {
    return Objects.hash(fileHashes.hashCode(), Arrays.hashCode(this.diffContent));
  }

  @Override
  public String toString() {
    return String.format("%s\n%s\n", fileHashes, new String(diffContent, UTF_8));
  }

  private static void validateParsedPathValue(String path) throws ValidationException {
    try {
      var unused = Path.of(path);
    } catch (InvalidPathException e) {
      throw new ValidationException(String.format("Parsed path value is invalid: %s.", path), e);
    }
  }

  private static void validateParsedHashValue(String hash)
      throws ValidationException {
    try {
      var unused = HashCode.fromString(hash);
    } catch (IllegalArgumentException e) {
      throw new ValidationException(String.format("Parsed hash value is invalid: %s.", hash), e);
    }
  }
}
